angular.module('ui.bootstrap.typeahead', ['ui.bootstrap.position', 'ui.bootstrap.bindHtml'])

/**
 * A helper service that can parse typeahead's syntax (string provided by users)
 * Extracted to a separate service for ease of unit testing
 */
    .factory('typeaheadParser', ['$parse', function ($parse) {

      //                      00000111000000000000022200000000000000003333333333333330000000000044000
      var TYPEAHEAD_REGEXP = /^\s*([\s\S]+?)(?:\s+as\s+([\s\S]+?))?\s+for\s+(?:([\$\w][\$\w\d]*))\s+in\s+([\s\S]+?)$/;

      return {
        parse:function (input) {

          var match = input.match(TYPEAHEAD_REGEXP);
          if (!match) {
            throw new Error(
                    'Expected typeahead specification in form of "_modelValue_ (as _label_)? for _item_ in _collection_"' +
                    ' but got "' + input + '".');
          }

          return {
            itemName:match[3],
            source:$parse(match[4]),
            viewMapper:$parse(match[2] || match[1]),
            modelMapper:$parse(match[1])
          };
        }
      };
    }])

    .directive('typeahead', ['$compile', '$parse', '$q', '$timeout', '$document', '$position', 'typeaheadParser',
      function ($compile, $parse, $q, $timeout, $document, $position, typeaheadParser) {

        var HOT_KEYS = [9, 13, 27, 38, 40];

        return {
          require:'ngModel',
          link:function (originalScope, element, attrs, modelCtrl) {

            //SUPPORTED ATTRIBUTES (OPTIONS)

            //minimal no of characters that needs to be entered before typeahead kicks-in
            var minSearch = originalScope.$eval(attrs.typeaheadMinLength) || 1;

            //minimal wait time after last character typed before typehead kicks-in
            var waitTime = originalScope.$eval(attrs.typeaheadWaitMs) || 0;

            //should it restrict model values to the ones selected from the popup only?
            var isEditable = originalScope.$eval(attrs.typeaheadEditable) !== false;

            //binding to a variable that indicates if matches are being retrieved asynchronously
            var isLoadingSetter = $parse(attrs.typeaheadLoading).assign || angular.noop;

            //a callback executed when a match is selected
            var onSelectCallback = $parse(attrs.typeaheadOnSelect);

            var inputFormatter = attrs.typeaheadInputFormatter ? $parse(attrs.typeaheadInputFormatter) : undefined;

            var appendToBody =  attrs.typeaheadAppendToBody ? originalScope.$eval(attrs.typeaheadAppendToBody) : false;

            var focusFirst = originalScope.$eval(attrs.typeaheadFocusFirst) !== false;

            //INTERNAL VARIABLES

            //model setter executed upon match selection
            var $setModelValue = $parse(attrs.ngModel).assign;

            //expressions used by typeahead
            var parserResult = typeaheadParser.parse(attrs.typeahead);

            var hasFocus;

            //create a child scope for the typeahead directive so we are not polluting original scope
            //with typeahead-specific data (matches, query etc.)
            var scope = originalScope.$new();
            originalScope.$on('$destroy', function(){
              scope.$destroy();
            });

            // WAI-ARIA
            var popupId = 'typeahead-' + scope.$id + '-' + Math.floor(Math.random() * 10000);
            element.attr({
              'aria-autocomplete': 'list',
              'aria-expanded': false,
              'aria-owns': popupId
            });

            //pop-up element used to display matches
            var popUpEl = angular.element('<div typeahead-popup></div>');
            popUpEl.attr({
              id: popupId,
              matches: 'matches',
              active: 'activeIdx',
              select: 'select(activeIdx)',
              query: 'query',
              position: 'position'
            });
            //custom item template
            if (angular.isDefined(attrs.typeaheadTemplateUrl)) {
              popUpEl.attr('template-url', attrs.typeaheadTemplateUrl);
            }

            var resetMatches = function() {
              scope.matches = [];
              scope.activeIdx = -1;
              element.attr('aria-expanded', false);
            };

            var getMatchId = function(index) {
              return popupId + '-option-' + index;
            };

            // Indicate that the specified match is the active (pre-selected) item in the list owned by this typeahead.
            // This attribute is added or removed automatically when the `activeIdx` changes.
            scope.$watch('activeIdx', function(index) {
              if (index < 0) {
                element.removeAttr('aria-activedescendant');
              } else {
                element.attr('aria-activedescendant', getMatchId(index));
              }
            });

            var getMatchesAsync = function(inputValue) {

              var locals = {$viewValue: inputValue};
              isLoadingSetter(originalScope, true);
              $q.when(parserResult.source(originalScope, locals)).then(function(matches) {

                //it might happen that several async queries were in progress if a user were typing fast
                //but we are interested only in responses that correspond to the current view value
                var onCurrentRequest = (inputValue === modelCtrl.$viewValue);
                if (onCurrentRequest && hasFocus) {
                  if (matches.length > 0) {

                    scope.activeIdx = focusFirst ? 0 : -1;
                    scope.matches.length = 0;

                    //transform labels
                    for(var i=0; i<matches.length; i++) {
                      locals[parserResult.itemName] = matches[i];
                      scope.matches.push({
                        id: getMatchId(i),
                        label: parserResult.viewMapper(scope, locals),
                        model: matches[i]
                      });
                    }

                    scope.query = inputValue;
                    //position pop-up with matches - we need to re-calculate its position each time we are opening a window
                    //with matches as a pop-up might be absolute-positioned and position of an input might have changed on a page
                    //due to other elements being rendered
                    scope.position = appendToBody ? $position.offset(element) : $position.position(element);
                    scope.position.top = scope.position.top + element.prop('offsetHeight');

                    element.attr('aria-expanded', true);
                  } else {
                    resetMatches();
                  }
                }
                if (onCurrentRequest) {
                  isLoadingSetter(originalScope, false);
                }
              }, function(){
                resetMatches();
                isLoadingSetter(originalScope, false);
              });
            };

            resetMatches();

            //we need to propagate user's query so we can higlight matches
            scope.query = undefined;

            //Declare the timeout promise var outside the function scope so that stacked calls can be cancelled later
            var timeoutPromise;

            var scheduleSearchWithTimeout = function(inputValue) {
              timeoutPromise = $timeout(function () {
                getMatchesAsync(inputValue);
              }, waitTime);
            };

            var cancelPreviousTimeout = function() {
              if (timeoutPromise) {
                $timeout.cancel(timeoutPromise);
              }
            };

            //plug into $parsers pipeline to open a typeahead on view changes initiated from DOM
            //$parsers kick-in on all the changes coming from the view as well as manually triggered by $setViewValue
            modelCtrl.$parsers.unshift(function (inputValue) {

              hasFocus = true;

              if (inputValue && inputValue.length >= minSearch) {
                if (waitTime > 0) {
                  cancelPreviousTimeout();
                  scheduleSearchWithTimeout(inputValue);
                } else {
                  getMatchesAsync(inputValue);
                }
              } else {
                isLoadingSetter(originalScope, false);
                cancelPreviousTimeout();
                resetMatches();
              }

              if (isEditable) {
                return inputValue;
              } else {
                if (!inputValue) {
                  // Reset in case user had typed something previously.
                  modelCtrl.$setValidity('editable', true);
                  return inputValue;
                } else {
                  modelCtrl.$setValidity('editable', false);
                  return undefined;
                }
              }
            });

            modelCtrl.$formatters.push(function (modelValue) {

              var candidateViewValue, emptyViewValue;
              var locals = {};

              if (inputFormatter) {

                locals.$model = modelValue;
                return inputFormatter(originalScope, locals);

              } else {

                //it might happen that we don't have enough info to properly render input value
                //we need to check for this situation and simply return model value if we can't apply custom formatting
                locals[parserResult.itemName] = modelValue;
                candidateViewValue = parserResult.viewMapper(originalScope, locals);
                locals[parserResult.itemName] = undefined;
                emptyViewValue = parserResult.viewMapper(originalScope, locals);

                return candidateViewValue!== emptyViewValue ? candidateViewValue : modelValue;
              }
            });

            scope.select = function (activeIdx) {
              //called from within the $digest() cycle
              var locals = {};
              var model, item;

              locals[parserResult.itemName] = item = scope.matches[activeIdx].model;
              model = parserResult.modelMapper(originalScope, locals);
              $setModelValue(originalScope, model);
              modelCtrl.$setValidity('editable', true);

              onSelectCallback(originalScope, {
                $item: item,
                $model: model,
                $label: parserResult.viewMapper(originalScope, locals)
              });

              resetMatches();

              //return focus to the input element if a match was selected via a mouse click event
              // use timeout to avoid $rootScope:inprog error
              $timeout(function() { element[0].focus(); }, 0, false);
            };

            //bind keyboard events: arrows up(38) / down(40), enter(13) and tab(9), esc(27)
            element.bind('keydown', function (evt) {

              //typeahead is open and an "interesting" key was pressed
              if (scope.matches.length === 0 || HOT_KEYS.indexOf(evt.which) === -1) {
                return;
              }

              // if there's nothing selected (i.e. focusFirst) and enter is hit, don't do anything
              if (scope.activeIdx == -1 && (evt.which === 13 || evt.which === 9)) {
                return;
              }

              evt.preventDefault();

              if (evt.which === 40) {
                scope.activeIdx = (scope.activeIdx + 1) % scope.matches.length;
                scope.$digest();

              } else if (evt.which === 38) {
                scope.activeIdx = (scope.activeIdx > 0 ? scope.activeIdx : scope.matches.length) - 1;
                scope.$digest();

              } else if (evt.which === 13 || evt.which === 9) {
                scope.$apply(function () {
                  scope.select(scope.activeIdx);
                });

              } else if (evt.which === 27) {
                evt.stopPropagation();

                resetMatches();
                scope.$digest();
              }
            });

            element.bind('blur', function (evt) {
              hasFocus = false;
            });

            // Keep reference to click handler to unbind it.
            var dismissClickHandler = function (evt) {
              if (element[0] !== evt.target) {
                resetMatches();
                scope.$digest();
              }
            };

            $document.bind('click', dismissClickHandler);

            originalScope.$on('$destroy', function(){
              $document.unbind('click', dismissClickHandler);
              if (appendToBody) {
                $popup.remove();
              }
            });

            var $popup = $compile(popUpEl)(scope);
            if (appendToBody) {
              $document.find('body').append($popup);
            } else {
              element.after($popup);
            }
          }
        };

      }])

    .directive('typeaheadPopup', function () {
      return {
        restrict:'EA',
        scope:{
          matches:'=',
          query:'=',
          active:'=',
          position:'=',
          select:'&'
        },
        replace:true,
        templateUrl:'../../catalog/lib/angular.ext/template/typeahead-popup.html',
        link:function (scope, element, attrs) {

          scope.templateUrl = attrs.templateUrl;

          scope.isOpen = function () {
            return scope.matches.length > 0;
          };

          scope.isActive = function (matchIdx) {
            return scope.active == matchIdx;
          };

          scope.selectActive = function (matchIdx) {
            scope.active = matchIdx;
          };

          scope.selectMatch = function (activeIdx) {
            scope.select({activeIdx:activeIdx});
          };
        }
      };
    })

    .directive('typeaheadMatch', ['$http', '$templateCache', '$compile', '$parse', function ($http, $templateCache, $compile, $parse) {
      return {
        restrict:'EA',
        scope:{
          index:'=',
          match:'=',
          query:'='
        },
        link:function (scope, element, attrs) {
          var tplUrl = $parse(attrs.templateUrl)(scope.$parent) || '../../catalog/lib/angular.ext/template/typeahead-match.html';
          $http.get(tplUrl, {cache: $templateCache}).success(function(tplContent){
            element.replaceWith($compile(tplContent.trim())(scope));
          });
        }
      };
    }])

    .filter('typeaheadHighlight', function() {

      function escapeRegexp(queryToEscape) {
        return queryToEscape.replace(/([.?*+^$[\]\\(){}|-])/g, '\\$1');
      }

      return function(matchItem, query) {
        return query ? ('' + matchItem).replace(new RegExp(escapeRegexp(query), 'gi'), '<strong>$&</strong>') : matchItem;
      };
    });